Análise: Desconto em Itens do Carrinho e Recomendados
Fonte de verdade: Código-fonte do aplicativo
Data de análise: 09/06/2026
Repositório: coezzion_vendas_app
Visão Geral
Esta análise documenta o comportamento atual do sistema de descontos ao operar sobre itens do carrinho e itens recomendados, separando as regras por fluxo de aplicação: desconto funcionário (gate por elegibilidade da API) e desconto manual (Novo Preço/Percentual).
Atenção: o gate que ativa o fluxo de funcionário é
EmployeeSalesController.isEligible.value— nãocustomer.isEmployee. Cliente funcionário pode cair no fluxo manual se a API retornarisEligible=falseou se a tela nem chegou a chamar a API (canal/saleEcommerce). Ver Seção 2.1.
Os itens recomendados são armazenados em cart.recommendedItems (lista separada
de cart.cartItems) e toda mutação sobre eles usa changeWithSkipTotals,
o que significa que valores de recomendados nunca recalculam totais do pedido.
1. Separação Estrutural: Cart vs Recomendados
| Propriedade | cartItems | recommendedItems |
|---|---|---|
| Modelo | List<CartItemDTO> | List<CartItemDTO>? |
Computa itemsDiscount | Sim | Não |
Computa totalWithoutShipmentAndDiscount | Sim | Não |
| Recalcula totais ao mutar | Sim (changeWithAsync) | Não (changeWithSkipTotals) |
| Enviado na API | Sim | Sim (cartItemRecommendations) |
Arquivo-chave: cart_controller.dart linha 612
/// Aplica mutação no cart sem recalcular totais.
/// Usado exclusivamente para operações em recommendedItems.
CartDTO? changeWithSkipTotals(ChangeCartFunction change) { ... }
2. Desconto Funcionário (gate por elegibilidade)
Importante: o gate que ativa este fluxo é
EmployeeSalesController.isEligible.value, nãocustomer.isEmployee. Cliente funcionário com elegibilidade negada pela API (ex.: cota anual esgotada) cai no fluxo de Não-Funcionário descrito na Seção 3.
2.1. Gate em Duas Camadas
Camada 1 — Decisão de chamar a API (check_sell_screen.dart:64-77)
if (cartForEligibility != null &&
cartForEligibility.saleEcommerce == false &&
cartForEligibility.customer?.isEmployee == true &&
(store?.channel == StoreChannel.lojaPropria ||
store?.channel == StoreChannel.outlet)) {
await employeeSalesController.fetchEligibility(...);
}
Pré-requisitos para sequer consultar a API de elegibilidade:
cartDTO != nullsaleEcommerce == false(loja física)customer.isEmployee == truestore.channel ∈ {lojaPropria, outlet}
Se qualquer um falhar, isEligible permanece false (default) e o bottom sheet
abre direto no fluxo manual.
Camada 2 — Decisão de qual UI exibir (check_sell_product_discount.dart:204)
Widget _renderDiscountFields(FormComposer formComposer) {
if (employeeSalesController.isEligible.value) {
return _EmployeeDiscountSection(...); // checkbox de funcionário
}
return Column(...); // tabs Novo Preço / Percentual
}
A API pode retornar isEligible=false mesmo para funcionário (sem cota, regra de negócio
backend etc.); neste caso a Camada 2 cai no fluxo manual.
2.2. Saldo de Pares
Arquivo: employee_sales_controller.dart
// Unidades restantes efetivas (API - já usadas no cart)
effectiveRemainingUnits = remainingUnitsFromApi - _currentCartEmployeeItems
isEligibleAndHasBalance = isEligible && effectiveRemainingUnits > 0
// Pode aplicar ao item se está elegível, tem saldo e o desconto funcionário
// é melhor que o desconto vigente do item (qualquer origem)
canApplyToItem(item) = isEligibleAndHasBalance && _employeeDiscountBetterThanCurrent(item)
_currentCartEmployeeItems tem três pontos de mutação:
| Local | Operação | Observação |
|---|---|---|
fetchEligibility:45 | inicialização: cartItems.where(isEmployeeDiscount).length | Itera apenas cartItems — não considera recommendedItems pré-existentes |
applyToItem:84 | _currentCartEmployeeItems++ | Conta corretamente quando aplicado em recomendados |
removeFromItem:93 | max(0, _currentCartEmployeeItems - 1) | Conta corretamente quando removido de recomendados |
Gap real (apenas na inicialização): se ao recarregar o cart já houver itens em
recommendedItemscomisEmployeeDiscount = true, o contador inicial subestima o consumo e o saldo aparenta ser maior do que de fato é. Aplicação/remoção subsequente está correta.
2.3. Comparação com desconto vigente (_employeeDiscountBetterThanCurrent)
// employee_sales_controller.dart:63-69
final currentDiscountPercent =
(item.unitPrice - item.unitPriceWithDiscount) / item.unitPrice * 100;
return discountPercentage.value! > currentDiscountPercent;
A comparação é genérica — vale para qualquer desconto vigente no item (promoção,
manual anterior, funcionário já aplicado). O warning específico de promoção só
aparece quando hasSaleDiscount(item) é verdadeiro (item desconto não-funcionário
com preço atual menor que unitPrice).
2.4. Aplicação do Desconto (applyToItem)
// employee_sales_controller.dart:76-86
unitPriceWithDiscount = double.parse(
(unitPrice * (1 - pct / 100)).toStringAsFixed(2)
)
discount = CartItemDTODiscountType.percentage
discountValue = pct
isEmployeeDiscount = true
_currentCartEmployeeItems++
- Desconto funcionário não está sujeito a
maxDiscountPercentda loja. - Estado do checkbox é controlado por
cartItemDTO.isEmployeeDiscount(mutado imediatamente peloonTap, sem aguardar o APLICAR final). CANCELARno bottom sheet reverte o estado se diferiu do original capturado em_originalIsEmployeeDiscount.
2.5. Mensagens de Aviso (_EmployeeDiscountSection:524-534)
| Condição | Mensagem | Bloqueia? |
|---|---|---|
hasSaleDiscount && canApply | "O desconto será aplicado sobre o preço cheio do produto." | Não (informativa) |
hasSaleDiscount && !canApply && !isAlreadyApplied | "O desconto de funcionário é menor que o preço promocional atual." | Sim (checkbox desabilitado) |
!isEligibleAndHasBalance && !isAlreadyApplied | "Limite de pares com desconto atingido." | Sim (sem saldo de cota) |
2.6. Fluxograma: Funcionário
3. Desconto Manual (fluxo Não-Funcionário)
Aplicado quando EmployeeSalesController.isEligible.value == false,
independentemente de o cliente ser funcionário ou não.
3.1. Pré-Gate de Visibilidade (hasMaxDiscountPercent)
Arquivos: check_sell_products.dart (cart) e check_sell_recommended.dart:291
(recomendados)
Visibility(
visible: checkSellController.hasMaxDiscountPercent,
child: ... ZzTextButton('EDITAR') ...,
)
Se maxDiscountPercent <= 0, o botão EDITAR fica invisível e o bottom sheet
nunca abre. O desconto manual está completamente bloqueado.
3.2. UI: Dois Modos de Entrada
Arquivo: check_sell_product_discount.dart
| Modo | Campo | Validação |
|---|---|---|
| Novo preço | Valor monetário | fieldMinMaxValue(val, minAllowedPrice, unitPrice) |
| Percentual | Inteiro 0–99 | fieldMinMaxValuePercentIntegerProduct(val, 0, maxAllowedPercent) |
Ambos delegam ao CartController que delega ao DiscountLimitUtils.
3.3. Limite Máximo por Item
Arquivo: discount_limit_utils.dart linha 61
maxByItem = unitPrice × (maxDiscountPercent / 100)
currentItemDiscount = getCartItemDiscountValue(item)
otherItemsDiscount = max(0, cart.itemsDiscount - currentItemDiscount)
cartMaxDiscount = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
remainingForThisItem = cartMaxDiscount - otherItemsDiscount
allowedDiscount = min(maxByItem, remainingForThisItem)
return max(0, round2(allowedDiscount))
Gap:
cart.itemsDiscountsoma apenascartItems. Descontos emrecommendedItemsnão entram emotherItemsDiscount. A premissa exige que entrem.
3.4. Limite Máximo do Pedido (Combinado)
Arquivo: discount_limit_utils.dart linha 20 e 29
maxAllowed = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
currentApplied = itemsDiscount + (crmBonus.rescuedBonus ?? 0)
exceeded = currentApplied - maxAllowed → se > 0: limite excedido
Gap:
itemsDiscountnão inclui descontos derecommendedItems.
3.5. Pós-aplicação: Recálculo de CRM
Arquivo: check_sell_product_discount.dart linha 266-289
if (hasAppliedCrmBonus &&
isCombinedDiscountLimitExceededForCurrentStore(cartValue: updatedCart)) {
cartController.clearCrmBonusExceedsEffectiveLimitFlag();
// ... fecha bottom sheet ...
cartController.requestCrmBonusBottomSheetReopen(
keepCurrentValue: true,
showValidationError: true,
clearAppliedValueOnDismiss: true,
);
}
Este recálculo de CRM só ocorre no caminho de cart items (onConfirmDiscount == null).
Itens recomendados pulam essa verificação porque o callback recomendado encerra
o fluxo antes (linha 256-259).
3.6. Fluxograma: Não-Funcionário
4. Comportamento de Totais com Recomendados
| Cálculo | Inclui recomendados? | Arquivo |
|---|---|---|
itemsDiscount | Não | cart_dto.dart:61 - itera cartItems |
totalWithoutShipmentAndDiscount | Não | cart_dto.dart - itera cartItems |
total (pedido) | Não | cart_dto.dart:374 |
isCombinedDiscountLimitExceeded | Não | usa itemsDiscount |
_currentCartEmployeeItems (inicialização) | Não (gap) | employee_sales_controller.dart:45 |
_currentCartEmployeeItems (aplicação/remoção) | Sim | employee_sales_controller.dart:84,93 |
5. Gaps vs Premissas Requeridas
| # | Premissa | Comportamento Atual | Gap |
|---|---|---|---|
| 1 | Qtd máxima de pares = cart + recomendados | Apenas a inicialização em fetchEligibility:45 filtra somente cartItems. Mutações via applyToItem/removeFromItem contam corretamente recomendados. | Reinício/recarregamento de carrinho com recomendados já marcados isEmployeeDiscount=true subestima o contador. |
| 2 | Max discount por item considera cart + recomendados | getMaxAllowedDiscountValueForCartItem usa itemsDiscount (só cartItems) | otherItemsDiscount não inclui desconto de recomendados |
| 3 | Max discount do pedido = soma cart + recomendados | isCombinedDiscountLimitExceeded usa itemsDiscount (só cartItems) | itemsDiscount em CartDTO não itera recommendedItems |
| 4 | Recálculo de CRM após desconto em recomendado | check_sell_product_discount.dart:266-289 só roda quando onConfirmDiscount == null (cart items) | Itens recomendados pulam a verificação de limite combinado e a reabertura de CRM bottom sheet |
6. Fórmulas Resumidas (Validadas no Código)
Preço do Item
// Funcionário (employee_sales_controller.dart:78)
unitPriceWithDiscount = round2(unitPrice × (1 - pct / 100))
// Não-funcionário valor (check_sell_controller.dart:96)
discountValue = unitPrice - novoPreco
unitPriceWithDiscount = unitPrice - discountValue
// Não-funcionário percentual (check_sell_controller.dart:98-101)
discount = unitPrice × (pct / 100)
unitPriceWithDiscount = round2(unitPrice - discount)
Desconto Efetivo por Item
// discount_limit_utils.dart:11
getCartItemDiscountValue:
value → discountValue
percentage → unitPrice - unitPriceWithDiscount
none → 0
Limite por Item
// discount_limit_utils.dart:61
maxByItem = unitPrice × (maxDiscountPercent / 100)
otherItemsDiscount = max(0, itemsDiscount - thisItemDiscount)
cartMaxDiscount = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
remainingForThisItem = cartMaxDiscount - otherItemsDiscount
allowedDiscount = min(maxByItem, remainingForThisItem)
// Derivados
minAllowedPrice = unitPrice - allowedDiscount
maxAllowedPercent = floor(allowedDiscount / unitPrice × 100).clamp(0, 99)
Limite do Pedido
// discount_limit_utils.dart:20-58
maxAllowed = totalWithoutShipmentAndDiscount × (maxDiscountPercent / 100)
currentApplied = itemsDiscount + rescuedBonus
exceeded = currentApplied - maxAllowed → exceeded > 0 = limite violado
Elegibilidade Funcionário
// Gate Camada 1 (check_sell_screen.dart:64-77)
deveChamarApi = cart != null
&& saleEcommerce == false
&& customer.isEmployee == true
&& store.channel ∈ {lojaPropria, outlet}
// Gate Camada 2 (check_sell_product_discount.dart:204)
mostraFluxoFuncionario = isEligible.value // setado a partir da resposta da API
// Saldo e aplicabilidade (employee_sales_controller.dart:21-25,59-69)
effectiveRemainingUnits = remainingUnitsFromApi - _currentCartEmployeeItems
isEligibleAndHasBalance = isEligible && effectiveRemainingUnits > 0
canApplyToItem(item) = isEligibleAndHasBalance
&& discountPct > currentDiscountPct(item)
CRM Efetivo
// discount_limit_utils.dart:128
crmByPercentage = itemsTotal × (percentageCrm / 100)
crmByLimit = maxAllowed - itemsDiscount
effectiveLimit = min(crmByPercentage, min(crmByLimit, totalBonus))
7. Arquivos Chave
| Arquivo | Responsabilidade |
|---|---|
discount_limit_utils.dart | CORE - todas as fórmulas de limite |
cart_dto.dart | Model do carrinho; itemsDiscount e recommendedItems |
cart_item_dto.dart | Model do item; campos de desconto |
cart_controller.dart | Lifecycle; changeWithSkipTotals para recomendados |
check_sell_screen.dart | Camada 1 do gate de elegibilidade (decide chamar API) |
employee_sales_controller.dart | Elegibilidade, contador de cota e aplicação de desconto funcionário |
check_sell_product_discount.dart | Camada 2 do gate (decide qual UI) + form e validação |
check_sell_recommended.dart | UI dos cards recomendados + botão EDITAR |
store_payment_config.dart | Configuração maxDiscount por loja |